iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
Build on Google AI

把履歷修改工程化:從「聽起來有道理」到用 Gemini 打造可驗證的 AI 職涯助理系列 第 23 篇

Day 23 - 不記錄整份履歷,也要排查問題:Logging 與 Monitoring 實作

  • 分享至 

  • xImage
  •  

Recap: 昨天確認 trace 裡沒有履歷,但 trace 外面的那 4 秒、出錯時的原因,要看 log

Day 17 刪 session 要看位元組,Day 22 開 trace 要掃屬性
log 也一樣:先看現在到底記了什麼,再決定要記什麼

先盤點:Agent Runtime 已經記了什麼

Day 22 開 telemetry 的時候,SDK 的說明寫著「Logging is always enabled when telemetry is enabled」
其實就算沒開,Agent Runtime 的 stdout、stderr 本來就會進 Cloud Logging

把這四天的 log 全部拉下來(扣掉部署時的建置 log)

entries: 1568  from 2026-10-03T05:16 to 2026-10-05T16:49
  1361 reasoning_engine_stderr
   128 reasoning_engine_stdout
    79 reasoning_engine_stderr  ERROR

用 Day 22 那支腳本的同一組字串掃一遍:公司名、email、電話、一句工作內容

needle hits: {'沛原科技': 1, 'boyu.chen': 1, '會員中心的登入與權限': 1, '0900-000-001': 1, '陳柏宇': 1, 'resume_text': 1}

有一筆
時間是 Day 21 下午,就是 ttl 設太短被拒絕的那次

ERROR: Error when invoking Agent Engine: 400 INVALID_ARGUMENT. ... `ttl` must be at least 24 hours. ...
Request is: `{'state': {'resume_text': '陳柏宇\n後端工程師 / 1 年經驗\nEmail: boyu.chen@exa...

Day 21 寫了 clean_error,把錯誤訊息裡的履歷切掉才回給前端
但那只顧到我們的回應;Agent Runtime 自己在伺服器那邊,把同一個錯誤連同整個請求寫進了它的 stderr
這一行不是我寫的程式印的,程式改得再乾淨也擋不到

79 個 ERROR,幾個是真的

要做監控,第一個想法是「ERROR 變多就通知我」
先看這 79 個是什麼

次數 是什麼 要不要管
32 Cloud Resource Manager API 沒開(Day 19 部署時看過,SDK 想把專案編號轉回 ID) 不用,是雜訊
12 gemini-2.5-flash 在 asia-east1 找不到(Day 19 的 404) 要,當時是真的壞了
17 InvocationFailureError、DynamicNodeFailError 包在上面那些外面的,不是新問題
8 Session … does not belong to user anon-…、Session not found Day 21 測陌生人存取留下的

一大半是雜訊
如果用「ERROR 數量」當警報,這幾天會一直響,響久了就沒人看
反而最後那 8 筆是有用的:有人拿別人的 session id 來試,平台有記下來

另外,每個 traceback 在 Cloud Logging 裡是一行一筆被拆開的,}、metadata { 這種碎片都是獨立的一筆 log
要看一個錯誤的全貌,得用時間把它們湊回去

我們這一層:白名單,不是黑名單

Agent Runtime 的 log 我管不到,但 FastAPI 的 log 是我寫的
目前 agent 的端點什麼都沒記,出事只能靠 uvicorn 的存取紀錄跟未處理例外的 traceback

新增 app/observability.py,原則只有一條:log 裡不能有履歷,出事的時候又要查得到原因

第一層:白名單

ALLOWED = {
    "event", "session_id", "owner", "status", "elapsed_ms",
    "llm_calls", "tool_calls", "prompt_tokens", "thought_tokens", "output_tokens", "total_tokens",
    "stopped", "unverified", "error_kind", "error", "backend", "resume_chars",
}

def log_event(event: str, level: int = logging.INFO, **fields) -> dict:
    kept = {k: v for k, v in fields.items() if k in ALLOWED and v is not None}
    ...

為什麼是白名單:黑名單寫「不要記 resume_text」,換個名字叫 text 就漏了
白名單的話,要多記一個欄位就得來這裡加,加的時候自然會想「這個欄位會不會有履歷」

每個欄位都刻意只記「數字」或「代碼」

  • tool_calls:工具的名字,不是參數(Day 22 看過,參數就是履歷原句)
  • unverified:找不到出處的引用有幾句,不是內容
  • owner:user_id 的前 12 碼,分得出是不是同一個人,還原不了
  • resume_chars:履歷有多長。之後如果要查「是不是太長所以慢」用得到,內容不記

第二層:安全網

白名單管的是「哪些欄位」,管不到欄位裡的字,也管不到別人的 log(uvicorn、SDK、未處理例外的 traceback)
所以 root logger 上再掛一個 filter,所有 log 寫出去之前都洗一次

_EMAIL = re.compile(r"[\w.+-]+@[\w-]+(\.[\w-]+)+")
_PHONE = re.compile(r"(?<!\d)09\d{2}[- ]?\d{3}[- ]?\d{3}(?!\d)")
_CUT = re.compile(r"(Request Data|resume_text).*", re.S)

def redact(text: str) -> str:
    text = _CUT.sub(r"\1…(已移除)", text)
    text = _EMAIL.sub("<email>", text)
    return _PHONE.sub("<phone>", text)

Request Data 是 Day 21 回到我們這邊的錯誤格式,resume_text 是 state 的 key,不管錯誤訊息怎麼措辭,只要它把 state 印出來就會有這個字

測試抓到的洞

寫測試的時候,故意讓 agent 丟出 Day 21 那個帶著履歷的錯誤,檢查寫出去的那一行

AssertionError: assert not ['陳柏宇', 'boyu.chen@example.invalid', '0900-000-001']

安全網只洗了 log 的訊息跟例外,沒洗 log_event 的欄位;而 error 欄位放的就是例外訊息
再往回追,本機的 runner 把模型的錯誤包成 AgentError(str(e)),根本沒經過 clean_error,只有雲端那條路有洗

兩邊都補了:filter 也洗欄位,本機的 runner 包錯誤之前也先洗
這個洞如果沒寫測試,要等到哪天本機真的出錯、log 裡多一份履歷才會發現

實際跑一次

uvicorn 用 AGENT_BACKEND=runtime 跑起來,照前端的順序:開對話、問一輪、陌生人來試、刪除

{"severity": "INFO", "message": "agent_session_started", "session_id": "<id>", "owner": "anon-a3d6d56", "backend": "runtime", "resume_chars": 621}
{"severity": "INFO", "message": "agent_turn", "session_id": "<id>", "owner": "anon-a3d6d56", "status": 200, "elapsed_ms": 15997, "llm_calls": 1, "tool_calls": [], "prompt_tokens": 1912, "thought_tokens": 931, "output_tokens": 1419, "total_tokens": 4262, "unverified": 6}
{"severity": "WARNING", "message": "agent_access_denied", "session_id": "<id>", "owner": "anon-2cd7e47", "status": 404}
{"severity": "INFO", "message": "agent_session_deleted", "session_id": "<id>", "owner": "anon-a3d6d56", "status": 204}

整個伺服器的輸出掃過一次,履歷的字串出現 0 次

四行裡一個使用者寫的字都沒有,但故事是完整的

  • 這一輪 16 秒、呼叫模型 1 次、4,262 tokens
  • tool_calls 是空的、unverified 是 6:agent 又沒驗證就引用了,guard 抓到 6 句(Day 21 看過一樣的事)
  • 有一個不同的人(anon-2cd7e47)拿這個 session id 來試,被擋了

最後那個 anon-2cd7e47,跟上面平台 log 裡「Session … does not belong to user anon-2cd7e476…」是同一個
Day 21 測陌生人的時候用的是同一個 token,兩邊的 log 對得起來
只記雜湊,一樣查得到「是不是同一個人一直在試」

平台那一筆:用排除規則

Agent Runtime 的 log 不是我寫的,只能在 Cloud Logging 的設定上處理
做法是在 _Default 這個 sink 上加一個排除規則:符合條件的 log 不存

一開始想直接用 Request Data 當條件,因為我們這邊看到的錯誤是這樣寫的
加之前先回去確認那一筆,結果它裡面根本沒有 Request Data,平台的寫法是 Request is:
同一個錯誤,回給呼叫端跟寫進伺服器 log 的措辭不一樣,照我的猜測寫,這個規則一筆都擋不到

改用 state 的 key resume_text 當條件,不管怎麼措辭,只要把 state 印出來就會有這個字

gcloud logging sinks update _Default \
  --add-exclusion='name=agent-runtime-resume-echo,filter=resource.type="aiplatform.googleapis.com/ReasoningEngine" AND textPayload:"resume_text"'

驗證

再故意觸發一次一樣的錯誤(ttl 給 3600 秒,不打模型),等兩分鐘去查

contain resume: 1

還在
把排除規則的條件直接拿去查 log,查得到那一筆,條件是對的
那筆的時間,離加規則才兩分鐘:規則還沒生效

過了五分鐘再觸發一次

resume_text entries since 11:01:16:  0
ttl-error tracebacks kept since 11:01:16:  2

帶著履歷的那一行沒存,但同一個錯誤的 traceback 留著,出事一樣查得到原因

Day 21 留下的那一筆,我沒刪
Cloud Logging 不能只刪一筆,要刪就是整個 stderr log 一起刪,排查用的 traceback 也會跟著不見
_Default 的保留期限是 30 天,而且那份是虛構的測試履歷,就讓它自己過期

Monitoring:閒置真的不收錢嗎

Day 19 把 min_instances 設成 0 的時候,一直沒辦法確認「沒人用的時候真的沒在計費」
Agent Runtime 在 Cloud Monitoring 有幾個現成的指標

reasoning_engine/request_count          請求數
reasoning_engine/request_latencies      延遲分布
reasoning_engine/cpu/allocation_time    CPU 配置秒數
reasoning_engine/memory/allocation_time 記憶體配置 GB 秒

CPU 配置時間就是計費的依據,以小時為單位拉這四天的資料

cpu/allocation_time(vCPU 秒)       request_count
  10/03 13:00      328.0               18
  10/04 18:00      255.8                9
  10/04 19:00       45.6                -
  10/04 22:00      231.9                1
  10/05 19:00      955.6               35
  10/06 00:00      249.5                4
  10/06 01:00       44.8                -

96 個小時裡,只有 7 個小時有配置 CPU,加起來 2,111 vCPU 秒,大約 0.6 個 vCPU 小時
沒人用的時候確實是 0

但也看得到每次「叫醒」的成本
10/04 22:00 只有 1 個請求,卻配置了 232 vCPU 秒:起一台、處理完、閒置一陣子才縮回 0
10/04 19:00 一個請求都沒有,也有 45.6 秒,那是前一個小時用完之後還沒縮回去的尾巴

所以 min_instances=0 省的是「一直開著」的錢,但每叫醒一次,都要付好幾分鐘的份
請求很零散的話,這個比例會很難看;這個 Day 29 算成本的時候再細算

保留多久、存在哪

到今天為止,履歷跟跟它有關的東西分散在這些地方

在哪 存什麼 多久 誰管
Agent Runtime Sessions 履歷全文、對話 24 小時(Day 21 的 ttl) 平台,我設定
Cloud Trace 時間、token 數,沒有內容 平台預設 Day 22 的設定
我們的 log 事件、數字、雜湊 看部署在哪 白名單 + 安全網
Agent Runtime 的 log 平台自己印的東西 30 天 排除規則擋掉帶 state 的那種

「履歷只屬於這次分析」,Day 17 只看了第一列
後面三列都是今天跟昨天才知道的

還沒做的

  • 沒有設警報。原因上面講了:ERROR 數量有一大半是雜訊。比較有意義的是用我們自己的結構化 log 做指標,例如 agent_turn_failed 的比例、unverified 大於 0 而且 tool_calls 是空的比例(agent 沒驗證就引用)
  • 排除規則是用字串比對,平台哪天改了印 state 的方式,就可能漏掉。這也是為什麼要定期像今天這樣掃一次

測試從 84 個變成 91 個,帶著履歷的錯誤訊息、陌生人存取,都照實際看到的格式寫成測試

版本:google-adk 2.8.0、google-cloud-aiplatform 2.1.0、gemini-2.5-flash

明天

今天的 log 裡,有 12 筆是模型 404、還有 Day 18 跑 eval 時撞到的 429
明天處理這些:模型逾時、429、工具失敗的時候,什麼該重試、重試幾次、什麼時候該直接告訴使用者


上一篇
Day 22 - 一次分析到底做了什麼?用 Trace 追蹤模型與工具呼叫
下一篇
Day 24 - 模型逾時、429、工具失敗怎麼辦?讓流程能重試,也知道何時停止
系列文
把履歷修改工程化:從「聽起來有道理」到用 Gemini 打造可驗證的 AI 職涯助理 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言